Skip to content

feat(admin): paginate audit and request logs - #155

Closed
erwill2 wants to merge 1 commit into
icoretech:mainfrom
erwill2:feat/admin-log-pagination
Closed

feat(admin): paginate audit and request logs#155
erwill2 wants to merge 1 commit into
icoretech:mainfrom
erwill2:feat/admin-log-pagination

Conversation

@erwill2

@erwill2 erwill2 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements item 11 by adding URL-driven pagination to both admin log views using one shared pagination component and helper:

  • /admin/audit-logs
  • /admin/request-logs

Both pages now render matching top and bottom controls, preserve active filters while navigating, omit page=1 from canonical URLs, show exact visible ranges, and clamp stale or overflowing page numbers.

This PR is based directly on current main (b628d7ea). The GitHub audit found no existing audit/request-log pagination PR or issue; the Jobs explorer is the closest existing UI precedent.

Context

The underlying readers already expose a stable offset-pagination contract:

  • items
  • exact filtered total
  • limit
  • offset

Audit events are ordered newest-first by their existing stable ordering, and request logs are ordered by admitted_at DESC, id DESC. The two LiveViews nevertheless always requested limit: 50 with the default offset, so records after the first 50 were inaccessible from the admin UI.

No accounting or audit query semantics needed to change. This PR wires the existing offset support into URL state and presentation.

Shared pagination design

CodexPoolerWeb.Admin.LogPagination owns the behavior shared by both pages:

  • parses and validates the page query parameter;
  • bounds pages to 1..10,000 before an SQL OFFSET can be constructed;
  • computes page offsets from the existing page size;
  • computes page count, previous/next state, and visible record range;
  • clamps requested pages to the last available page, including an empty result set;
  • builds deterministic, filter-preserving URLs;
  • removes page when navigating back to page 1;
  • renders the common accessible Previous/Next controls, page status, and range.

The audit and request-log components only supply route-specific IDs, labels, range roles, and border placement. This avoids maintaining two copies of the same pagination markup and arithmetic.

URL and state behavior

Situation Behavior
No page or blank page Load page 1
Valid page N Query with offset (N - 1) * 50
Invalid, structured, non-positive, or over-limit page Fall back to page 1 and expose validation feedback
Requested page exceeds the filtered result count Patch to the last available page while preserving filters
Requested page is greater than 1 and the result set is empty Patch to the canonical page-1 URL
Previous navigation reaches page 1 Remove the page query parameter
A request-log event refresh arrives while viewing page N Refresh page N; clamp only if the result count shrank below it
A filter changes Existing filter handlers produce a fresh filter URL and naturally reset to page 1

Request-log drawer state remains in current_params, so opening or closing a selected request continues to preserve the current page and filters.

Presentation

Both pages render pagination above and below their desktop/mobile log tables:

  • Page N of M
  • Showing X-Y of Z
  • disabled Previous/Next controls at the boundaries
  • filter-preserving LiveView patches
  • distinct accessible labels for top and bottom controls

Controls render whenever the filtered result has records, including a last page that becomes temporarily empty before LiveView applies its canonical clamp.

Realtime request-log refresh

Request logs can refresh from PostgreSQL-backed events. The LiveView now stores the current page separately from parsed filters and uses that page for lightweight event refreshes. If a new row arrives, the operator remains on the current page. If rows disappear and the page is no longer valid, the view patches to the new last page.

This keeps realtime behavior consistent with explicit pagination instead of unexpectedly returning operators to page 1.

Safety and access control

  • Pool and scope visibility still come from the existing Pools.list_log_filter_pools/1, audit readers, and request-log readers.
  • Selected-pool and all-visible-pools behavior is unchanged.
  • Pagination links preserve only the parameters already held by each LiveView.
  • Page input is bounded before calculating database offsets.
  • No raw log content, credentials, or sensitive metadata is added to URLs or pagination output.
  • Existing redaction and drawer access checks are unchanged.

Tests

The regression coverage verifies:

  • shared parsing for missing, blank, valid, malformed, structured, non-positive, and over-limit pages;
  • shared offset, clamp, metadata, navigation, range, and deterministic-path behavior;
  • audit-log page 1/page 2 navigation with filters preserved;
  • request-log page 1/page 2 navigation with filters preserved;
  • canonical removal of page=1;
  • exact Showing X-Y of Z ranges;
  • disabled controls at first/last-page boundaries;
  • overflow clamping to the last page;
  • empty-result clamping to page 1;
  • request-log event refresh while the operator remains on page 2;
  • existing audit/request rendering, filtering, authorization, redaction, drawer, and realtime tests.

Local verification:

  • mix compile --warnings-as-errors - passed in a clean build volume
  • complete shared-pagination, audit-log LiveView, and request-log LiveView suites - 48 passed
  • focused page-2 realtime refresh regression - 1 passed
  • mix credo --strict for all eight changed files - no issues
  • mix format --check-formatted for the new shared module and changed test files - passed
  • git diff --check - passed

Files changed

  • shared pagination component/helper
  • audit-log table component
  • request-log presentation component
  • audit-log LiveView
  • request-log LiveView
  • shared pagination unit tests
  • audit-log LiveView pagination tests
  • request-log LiveView pagination/realtime tests

Scope boundaries

This PR intentionally does not include:

  • the saved-reset quota fix from fix(upstreams): converge confirmed saved reset quota #153;
  • unrelated local admin UI changes;
  • a refactor of the existing Jobs explorer pagination;
  • changes to audit/accounting persistence or query contracts;
  • schema migrations or data backfills;
  • deployment or Portainer changes.

Risk and rollback

The pages use the repositories' existing offset pagination, so new records can shift later pages while an operator is browsing. That is the established reader contract and is preferable here to broadening this UI change into a cursor-pagination data-layer rewrite. Stable secondary ordering prevents nondeterministic ties within a snapshot.

Rollback is a single commit revert. There are no schema changes, backfills, or irreversible writes.

Add shared, filter-preserving pagination controls and bounded page parsing for both admin log views. Clamp empty and overflow pages, preserve request-log page state during event refresh, and cover the helper plus LiveView navigation regressions.
@masterkain

Copy link
Copy Markdown
Member

Thanks for this, @erwill2 — and sorry it sat open. Pagination for both log surfaces has now shipped from a separate line of work, so I am closing this PR rather than merging it. You are credited as co-author on 09f5eef4, because several decisions in it are yours.

What came from here:

  • Controls above the table rather than below. This was the thing that made the difference in practice: a pager at the bottom of a fifty-row table is unreachable on a phone without scrolling past everything you were reading. Ours ended up as a single sticky row at the top.
  • page in the URL as the unit of state, filters carried forward, page=1 kept out of the canonical URL.
  • Clamping the page number before it can become an OFFSET.
  • One shared module instead of two copies of the same arithmetic. CodexPoolerWeb.Admin.LogPagination exists for exactly the reason you gave; it lives under components/shared/ to match where the admin guide puts reusable presentation primitives.

What we did differently, and why.

The behaviour table in your description has this row:

A request-log event refresh arrives while viewing page N → Refresh page N; clamp only if the result count shrank below it

That is the case that does not work with offset alone. admitted_at and occurred_at are transaction timestamps, and rows written in the same transaction share them exactly — so a row inserted while the operator is on page 2 sorts above the cursor, every page shifts by one, and they silently re-read a row they have already seen or skip one they have not. On an audit log that is a hole in the trail with nothing to indicate it.

The window therefore pins to a cursor in the list's own sort key — the head row's {admitted_at, id} / {occurred_at, id}, carried as as_of + as_of_id — and the readers gained :at_or_before / :after filters for it, which intersect with the operator's date range rather than overriding it.

The same shape catches the other two rows of that table. Requested page exceeds the filtered result count → patch to the last available page while preserving filters and page > 1 and the result set is empty → patch to page 1 are both right as far as they go, but a page number that arrives without a cursor cannot be repaired by pinning it in place: the only cursor available then is the head of the page just loaded, so bounding the list there and re-applying the same offset lands past where the operator asked. Concretely, with 120 records, ?page=2 rendered rows 101–120 under "Page 2 of 2, showing 51–70 of 70" with Next disabled — rows 51–100 reachable from no page at all. That is fixed in cd13e427; an unpinned page now goes to the live first page.

One smaller divergence: an out-of-range page redirects silently instead of showing validation feedback. A stale bookmark is the common case for it, and a flash on every one of those reads as an error the operator did not make.

Finally, the discussion this PR started went somewhere you could not have anticipated: whether a paginated view should keep live-updating at all. It should not, but that is not pagination's decision to make — so 26c49264 added a global play/pause to the admin top bar, and every self-refreshing admin surface now asks the operator instead of guessing.

#11, which this implements, is closed. Thanks again — it was a real gap, and you were right about where the controls belong.

@masterkain masterkain closed this Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants